AWSTemplateFormatVersion: '2010-09-09'
Description: >
  Stopped EC2 Instance Review / Safe Termination Remediator.
  Purpose-specific, opt-in remediation module for identifying stopped EC2 instances
  older than a configured age threshold. It can optionally create AMIs and/or terminate
  explicitly approved stopped instances. This template is separate from the cost
  optimization assessment/reporting template.

Parameters:
  ProjectName:
    Type: String
    Default: stopped-ec2-instance-review-remediator
    Description: Base name used for created resources.

  TargetRegionsCsv:
    Type: String
    Default: all-enabled
    Description: Comma-separated AWS Regions to scan, or all-enabled to scan all enabled Regions.

  ExcludedRegionsCsv:
    Type: String
    Default: ap-northeast-3
    Description: Comma-separated AWS Regions to exclude from scanning/remediation.

  DryRun:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: When true, only report what would happen. When false, perform eligible actions.

  ActionMode:
    Type: String
    Default: review-only
    AllowedValues:
      - review-only
      - create-ami-only
      - terminate
      - create-ami-and-terminate
    Description: >
      Action to perform. review-only never changes resources. create-ami-only creates an AMI.
      terminate terminates eligible instances. create-ami-and-terminate creates an AMI first,
      then terminates the instance.

  MinimumStoppedAgeDays:
    Type: Number
    Default: 30
    Description: Only instances stopped for at least this many days are considered candidates.

  RequireApprovalTag:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Instance action requires ApprovalTagKey=ApprovalTagValue when true. Strongly recommended.

  ApprovalTagKey:
    Type: String
    Default: CostOptApproved
    Description: Tag key required when RequireApprovalTag is true.

  ApprovalTagValue:
    Type: String
    Default: 'true'
    Description: Tag value required when RequireApprovalTag is true.

  IgnoreTagKeysCsv:
    Type: String
    Default: CostOptIgnore,DoNotDelete,DoNotTerminate,DoNotModify
    Description: Comma-separated tag keys that prevent action when present, regardless of value.

  IgnoreTagPairsCsv:
    Type: String
    Default: CostOptIgnore=true,DoNotDelete=true,DoNotTerminate=true,DoNotModify=true
    Description: Comma-separated key=value tag pairs that prevent action when present.

  IncludeNamePrefixesCsv:
    Type: String
    Default: ''
    Description: Optional comma-separated EC2 Name tag prefixes to include. Leave blank to include all.

  ExcludeNamePrefixesCsv:
    Type: String
    Default: ''
    Description: Optional comma-separated EC2 Name tag prefixes to exclude.

  AmiNamePrefix:
    Type: String
    Default: costopt-stopped-instance-backup
    Description: Prefix used for AMIs created by this module.

  TerminateProtectionBehavior:
    Type: String
    Default: skip
    AllowedValues:
      - skip
      - disable-and-terminate
    Description: >
      Behavior when EC2 termination protection is enabled. skip is recommended.
      disable-and-terminate will disable API termination protection before termination.

  MaxInstancesPerRun:
    Type: Number
    Default: 25
    Description: Safety limit. 0 means no limit. Recommended first apply batch is 1 to 5.

  ContinueOnAccessDenied:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Continue scanning other Regions/resources when blocked by IAM/SCP.

  ScheduleEnabled:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: Whether to create an enabled EventBridge schedule.

  ScheduleExpression:
    Type: String
    Default: rate(30 days)
    Description: EventBridge schedule expression used when ScheduleEnabled is true.

Conditions:
  IsScheduleEnabled: !Equals [!Ref ScheduleEnabled, 'true']

Resources:
  RemediatorRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ProjectName}-${AWS::AccountId}-${AWS::Region}-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: StoppedEc2InstanceReviewPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: DiscoverAndReadEc2
                Effect: Allow
                Action:
                  - ec2:DescribeRegions
                  - ec2:DescribeInstances
                  - ec2:DescribeInstanceAttribute
                  - ec2:DescribeVolumes
                Resource: '*'
              - Sid: CreateAmiAndTags
                Effect: Allow
                Action:
                  - ec2:CreateImage
                  - ec2:CreateTags
                Resource: '*'
              - Sid: ModifyAndTerminateInstances
                Effect: Allow
                Action:
                  - ec2:ModifyInstanceAttribute
                  - ec2:TerminateInstances
                Resource: '*'

  RemediatorFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Description: Purpose-specific Lambda for stopped EC2 instance review and safe termination.
      Runtime: python3.12
      Handler: index.handler
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt RemediatorRole.Arn
      Environment:
        Variables:
          TARGET_REGIONS_CSV: !Ref TargetRegionsCsv
          EXCLUDED_REGIONS_CSV: !Ref ExcludedRegionsCsv
          DRY_RUN: !Ref DryRun
          ACTION_MODE: !Ref ActionMode
          MINIMUM_STOPPED_AGE_DAYS: !Ref MinimumStoppedAgeDays
          REQUIRE_APPROVAL_TAG: !Ref RequireApprovalTag
          APPROVAL_TAG_KEY: !Ref ApprovalTagKey
          APPROVAL_TAG_VALUE: !Ref ApprovalTagValue
          IGNORE_TAG_KEYS_CSV: !Ref IgnoreTagKeysCsv
          IGNORE_TAG_PAIRS_CSV: !Ref IgnoreTagPairsCsv
          INCLUDE_NAME_PREFIXES_CSV: !Ref IncludeNamePrefixesCsv
          EXCLUDE_NAME_PREFIXES_CSV: !Ref ExcludeNamePrefixesCsv
          AMI_NAME_PREFIX: !Ref AmiNamePrefix
          TERMINATE_PROTECTION_BEHAVIOR: !Ref TerminateProtectionBehavior
          MAX_INSTANCES_PER_RUN: !Ref MaxInstancesPerRun
          CONTINUE_ON_ACCESS_DENIED: !Ref ContinueOnAccessDenied
      Code:
        ZipFile: |
          import json
          import os
          import traceback
          from datetime import datetime, timezone

          import boto3
          from botocore.exceptions import ClientError

          def utc_now():
              return datetime.now(timezone.utc)

          def utc_now_iso():
              return utc_now().isoformat()

          def env_bool(name, default=False):
              value = os.environ.get(name, str(default)).strip().lower()
              return value in ('1', 'true', 'yes', 'y')

          def env_int(name, default=0):
              try:
                  return int(os.environ.get(name, str(default)).strip())
              except Exception:
                  return default

          def split_csv(value):
              if value is None:
                  return []
              return [item.strip() for item in str(value).split(',') if item.strip()]

          def parse_tag_pairs(value):
              pairs = {}
              for item in split_csv(value):
                  if '=' not in item:
                      continue
                  key, val = item.split('=', 1)
                  key = key.strip()
                  val = val.strip()
                  if key:
                      pairs[key] = val
              return pairs

          def tags_to_dict(tags):
              result = {}
              for tag in tags or []:
                  key = tag.get('Key')
                  if key:
                      result[key] = tag.get('Value', '')
              return result

          def get_enabled_regions(excluded_regions):
              requested = os.environ.get('TARGET_REGIONS_CSV', 'all-enabled').strip()
              if requested and requested.lower() != 'all-enabled':
                  regions = split_csv(requested)
              else:
                  ec2 = boto3.client('ec2', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
                  response = ec2.describe_regions(AllRegions=False)
                  regions = sorted([r['RegionName'] for r in response.get('Regions', [])])
              return [r for r in regions if r not in excluded_regions]

          def state_transition_time(instance):
              reason = instance.get('StateTransitionReason') or ''
              # Example: User initiated (2026-05-01 12:34:56 GMT)
              if '(' not in reason or ')' not in reason:
                  return None
              raw = reason.split('(', 1)[1].split(')', 1)[0].strip()
              if raw.endswith('GMT'):
                  raw = raw[:-3].strip()
              try:
                  return datetime.strptime(raw, '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc)
              except Exception:
                  return None

          def stopped_age_days(instance):
              stopped_at = state_transition_time(instance)
              if not stopped_at:
                  return None
              return (utc_now() - stopped_at).days

          def name_included(name, include_prefixes):
              if not include_prefixes:
                  return True
              return any(name.startswith(prefix) for prefix in include_prefixes)

          def name_excluded(name, exclude_prefixes):
              if not exclude_prefixes:
                  return False
              return any(name.startswith(prefix) for prefix in exclude_prefixes)

          def has_ignore_tag(tag_map, ignore_tag_keys, ignore_tag_pairs):
              for key in ignore_tag_keys:
                  if key in tag_map:
                      return True, 'ignore_tag_key=' + key
              for key, value in ignore_tag_pairs.items():
                  if tag_map.get(key) == value:
                      return True, 'ignore_tag_pair=' + key + '=' + value
              return False, ''

          def has_approval_tag(tag_map, settings):
              if not settings['require_approval_tag']:
                  return True
              return tag_map.get(settings['approval_tag_key']) == settings['approval_tag_value']

          def get_termination_protection(ec2, instance_id):
              response = ec2.describe_instance_attribute(InstanceId=instance_id, Attribute='disableApiTermination')
              return bool(response.get('DisableApiTermination', {}).get('Value', False))

          def attached_ebs_summary(ec2, instance):
              volumes = []
              for mapping in instance.get('BlockDeviceMappings', []):
                  ebs = mapping.get('Ebs') or {}
                  volume_id = ebs.get('VolumeId')
                  if volume_id:
                      volumes.append({
                          'volume_id': volume_id,
                          'device_name': mapping.get('DeviceName'),
                          'delete_on_termination': ebs.get('DeleteOnTermination')
                      })
              if not volumes:
                  return []
              try:
                  response = ec2.describe_volumes(VolumeIds=[v['volume_id'] for v in volumes])
                  by_id = {v['VolumeId']: v for v in response.get('Volumes', [])}
                  for item in volumes:
                      vol = by_id.get(item['volume_id'], {})
                      item['size_gib'] = vol.get('Size')
                      item['volume_type'] = vol.get('VolumeType')
                      item['encrypted'] = vol.get('Encrypted')
                      item['availability_zone'] = vol.get('AvailabilityZone')
              except ClientError:
                  pass
              return volumes

          def create_backup_ami(ec2, instance, settings, region):
              instance_id = instance.get('InstanceId')
              tag_map = tags_to_dict(instance.get('Tags', []))
              name = tag_map.get('Name', instance_id)
              clean_name = ''.join(c if c.isalnum() or c in ('-', '_', '.') else '-' for c in name)[:80]
              ami_name = f"{settings['ami_name_prefix']}-{clean_name}-{instance_id}-{utc_now().strftime('%Y%m%d-%H%M%S')}"
              response = ec2.create_image(
                  InstanceId=instance_id,
                  Name=ami_name,
                  Description=f"CostOpt remediation AMI for stopped instance {instance_id} created {utc_now_iso()}",
                  NoReboot=True
              )
              image_id = response.get('ImageId')
              if image_id:
                  ec2.create_tags(Resources=[image_id], Tags=[
                      {'Key': 'CreatedBy', 'Value': 'stopped-ec2-instance-review-remediator'},
                      {'Key': 'SourceInstanceId', 'Value': instance_id},
                      {'Key': 'CostOptRemediation', 'Value': 'true'},
                      {'Key': 'CreatedAt', 'Value': utc_now_iso()}
                  ])
              return image_id, ami_name

          def scan_region(region, settings):
              ec2 = boto3.client('ec2', region_name=region)
              result = {
                  'region': region,
                  'evaluated': 0,
                  'stopped_instances': 0,
                  'old_stopped_instances': 0,
                  'eligible': 0,
                  'would_create_ami': 0,
                  'created_ami': 0,
                  'would_terminate': 0,
                  'terminated': 0,
                  'skipped_not_stopped': 0,
                  'skipped_unknown_stopped_age': 0,
                  'skipped_too_new': 0,
                  'skipped_include_prefix': 0,
                  'skipped_exclude_prefix': 0,
                  'skipped_missing_approval_tag': 0,
                  'skipped_ignore_tag': 0,
                  'skipped_termination_protection': 0,
                  'access_denied': 0,
                  'failed': 0,
                  'failures': [],
                  'candidates': [],
                  'created_amis': [],
                  'terminated_instances': []
              }

              try:
                  paginator = ec2.get_paginator('describe_instances')
                  pages = paginator.paginate(Filters=[{'Name': 'instance-state-name', 'Values': ['pending','running','stopping','stopped']}])
              except ClientError as e:
                  code = e.response.get('Error', {}).get('Code', 'Unknown')
                  message = e.response.get('Error', {}).get('Message', str(e))
                  result['failures'].append({'operation': 'DescribeInstances', 'error_code': code, 'error_message': message})
                  if code in ('AccessDenied', 'AccessDeniedException', 'UnauthorizedOperation') or 'explicit deny' in message.lower():
                      result['access_denied'] += 1
                  else:
                      result['failed'] += 1
                  if not settings['continue_on_access_denied']:
                      raise
                  return result

              for page in pages:
                  for reservation in page.get('Reservations', []):
                      for instance in reservation.get('Instances', []):
                          if settings['max_instances_per_run'] and (result['would_terminate'] + result['terminated'] + result['would_create_ami'] + result['created_ami']) >= settings['max_instances_per_run']:
                              result['stopped_due_to_max_instances_per_run'] = True
                              return result

                          result['evaluated'] += 1
                          instance_id = instance.get('InstanceId')
                          state = (instance.get('State') or {}).get('Name')
                          tag_map = tags_to_dict(instance.get('Tags', []))
                          name = tag_map.get('Name', '')
                          base = {
                              'instance_id': instance_id,
                              'name': name,
                              'region': region,
                              'state': state,
                              'instance_type': instance.get('InstanceType'),
                              'private_ip': instance.get('PrivateIpAddress'),
                              'vpc_id': instance.get('VpcId'),
                              'subnet_id': instance.get('SubnetId'),
                              'launch_time': str(instance.get('LaunchTime')),
                              'state_transition_reason': instance.get('StateTransitionReason'),
                              'tags': tag_map
                          }

                          if state != 'stopped':
                              result['skipped_not_stopped'] += 1
                              continue

                          result['stopped_instances'] += 1
                          age_days = stopped_age_days(instance)
                          base['stopped_age_days'] = age_days
                          base['attached_ebs_volumes'] = attached_ebs_summary(ec2, instance)

                          if age_days is None:
                              result['skipped_unknown_stopped_age'] += 1
                              item = dict(base)
                              item['action'] = 'skipped_unknown_stopped_age'
                              result['candidates'].append(item)
                              continue

                          if age_days < settings['minimum_stopped_age_days']:
                              result['skipped_too_new'] += 1
                              item = dict(base)
                              item['action'] = 'skipped_too_new'
                              result['candidates'].append(item)
                              continue

                          result['old_stopped_instances'] += 1

                          if not name_included(name, settings['include_name_prefixes']):
                              result['skipped_include_prefix'] += 1
                              continue

                          if name_excluded(name, settings['exclude_name_prefixes']):
                              result['skipped_exclude_prefix'] += 1
                              item = dict(base)
                              item['action'] = 'skipped_exclude_prefix'
                              result['candidates'].append(item)
                              continue

                          ignore, ignore_reason = has_ignore_tag(tag_map, settings['ignore_tag_keys'], settings['ignore_tag_pairs'])
                          if ignore:
                              result['skipped_ignore_tag'] += 1
                              item = dict(base)
                              item['action'] = 'skipped_ignore_tag'
                              item['reason'] = ignore_reason
                              result['candidates'].append(item)
                              continue

                          if not has_approval_tag(tag_map, settings):
                              result['skipped_missing_approval_tag'] += 1
                              item = dict(base)
                              item['action'] = 'skipped_missing_approval_tag'
                              item['required_tag'] = settings['approval_tag_key'] + '=' + settings['approval_tag_value']
                              result['candidates'].append(item)
                              continue

                          try:
                              termination_protected = get_termination_protection(ec2, instance_id)
                          except ClientError as e:
                              code = e.response.get('Error', {}).get('Code', 'Unknown')
                              message = e.response.get('Error', {}).get('Message', str(e))
                              result['failures'].append({'instance_id': instance_id, 'operation': 'DescribeInstanceAttribute', 'error_code': code, 'error_message': message})
                              result['failed'] += 1
                              if not settings['continue_on_access_denied']:
                                  raise
                              continue

                          base['termination_protected'] = termination_protected
                          wants_ami = settings['action_mode'] in ('create-ami-only', 'create-ami-and-terminate')
                          wants_terminate = settings['action_mode'] in ('terminate', 'create-ami-and-terminate')

                          if wants_terminate and termination_protected and settings['terminate_protection_behavior'] == 'skip':
                              result['skipped_termination_protection'] += 1
                              item = dict(base)
                              item['action'] = 'skipped_termination_protection'
                              result['candidates'].append(item)
                              continue

                          result['eligible'] += 1
                          item = dict(base)
                          item['action_mode'] = settings['action_mode']

                          if settings['action_mode'] == 'review-only':
                              item['action'] = 'review_only_candidate'
                              result['candidates'].append(item)
                              continue

                          if settings['dry_run']:
                              if wants_ami:
                                  result['would_create_ami'] += 1
                              if wants_terminate:
                                  result['would_terminate'] += 1
                              item['action'] = 'would_' + settings['action_mode']
                              result['candidates'].append(item)
                              continue

                          image_id = None
                          ami_name = None
                          if wants_ami:
                              try:
                                  image_id, ami_name = create_backup_ami(ec2, instance, settings, region)
                                  result['created_ami'] += 1
                                  result['created_amis'].append({'instance_id': instance_id, 'image_id': image_id, 'ami_name': ami_name})
                              except ClientError as e:
                                  code = e.response.get('Error', {}).get('Code', 'Unknown')
                                  message = e.response.get('Error', {}).get('Message', str(e))
                                  result['failures'].append({'instance_id': instance_id, 'operation': 'CreateImage', 'error_code': code, 'error_message': message})
                                  if code in ('AccessDenied', 'AccessDeniedException', 'UnauthorizedOperation') or 'explicit deny' in message.lower():
                                      result['access_denied'] += 1
                                  else:
                                      result['failed'] += 1
                                  if not settings['continue_on_access_denied']:
                                      raise
                                  continue

                          if wants_terminate:
                              try:
                                  if termination_protected and settings['terminate_protection_behavior'] == 'disable-and-terminate':
                                      ec2.modify_instance_attribute(InstanceId=instance_id, DisableApiTermination={'Value': False})
                                  ec2.terminate_instances(InstanceIds=[instance_id])
                                  result['terminated'] += 1
                                  term_record = dict(item)
                                  term_record['created_image_id'] = image_id
                                  term_record['created_ami_name'] = ami_name
                                  term_record['action'] = 'terminate'
                                  result['terminated_instances'].append(term_record)
                              except ClientError as e:
                                  code = e.response.get('Error', {}).get('Code', 'Unknown')
                                  message = e.response.get('Error', {}).get('Message', str(e))
                                  result['failures'].append({'instance_id': instance_id, 'operation': 'TerminateInstances', 'error_code': code, 'error_message': message})
                                  if code in ('AccessDenied', 'AccessDeniedException', 'UnauthorizedOperation') or 'explicit deny' in message.lower():
                                      result['access_denied'] += 1
                                  else:
                                      result['failed'] += 1
                                  if not settings['continue_on_access_denied']:
                                      raise

              return result

          def handler(event, context):
              settings = {
                  'excluded_regions': split_csv(os.environ.get('EXCLUDED_REGIONS_CSV', '')),
                  'dry_run': env_bool('DRY_RUN', True),
                  'action_mode': os.environ.get('ACTION_MODE', 'review-only'),
                  'minimum_stopped_age_days': env_int('MINIMUM_STOPPED_AGE_DAYS', 30),
                  'require_approval_tag': env_bool('REQUIRE_APPROVAL_TAG', True),
                  'approval_tag_key': os.environ.get('APPROVAL_TAG_KEY', 'CostOptApproved'),
                  'approval_tag_value': os.environ.get('APPROVAL_TAG_VALUE', 'true'),
                  'ignore_tag_keys': split_csv(os.environ.get('IGNORE_TAG_KEYS_CSV', '')),
                  'ignore_tag_pairs': parse_tag_pairs(os.environ.get('IGNORE_TAG_PAIRS_CSV', '')),
                  'include_name_prefixes': split_csv(os.environ.get('INCLUDE_NAME_PREFIXES_CSV', '')),
                  'exclude_name_prefixes': split_csv(os.environ.get('EXCLUDE_NAME_PREFIXES_CSV', '')),
                  'ami_name_prefix': os.environ.get('AMI_NAME_PREFIX', 'costopt-stopped-instance-backup'),
                  'terminate_protection_behavior': os.environ.get('TERMINATE_PROTECTION_BEHAVIOR', 'skip'),
                  'max_instances_per_run': env_int('MAX_INSTANCES_PER_RUN', 25),
                  'continue_on_access_denied': env_bool('CONTINUE_ON_ACCESS_DENIED', True),
              }

              if isinstance(event, dict):
                  if 'dry_run' in event:
                      settings['dry_run'] = bool(event['dry_run'])
                  if 'action_mode' in event:
                      settings['action_mode'] = str(event['action_mode'])
                  if 'minimum_stopped_age_days' in event:
                      settings['minimum_stopped_age_days'] = int(event['minimum_stopped_age_days'])
                  if 'require_approval_tag' in event:
                      settings['require_approval_tag'] = bool(event['require_approval_tag'])
                  if 'include_name_prefixes' in event:
                      settings['include_name_prefixes'] = list(event['include_name_prefixes'])
                  if 'exclude_name_prefixes' in event:
                      settings['exclude_name_prefixes'] = list(event['exclude_name_prefixes'])
                  if 'max_instances_per_run' in event:
                      settings['max_instances_per_run'] = int(event['max_instances_per_run'])
                  if 'target_regions' in event and event['target_regions']:
                      os.environ['TARGET_REGIONS_CSV'] = ','.join(event['target_regions'])
                  if 'excluded_regions' in event and event['excluded_regions']:
                      settings['excluded_regions'] = list(event['excluded_regions'])

              if settings['action_mode'] not in ('review-only', 'create-ami-only', 'terminate', 'create-ami-and-terminate'):
                  settings['action_mode'] = 'review-only'
              if settings['terminate_protection_behavior'] not in ('skip', 'disable-and-terminate'):
                  settings['terminate_protection_behavior'] = 'skip'

              summary = {
                  'started_at': utc_now_iso(),
                  'dry_run': settings['dry_run'],
                  'action_mode': settings['action_mode'],
                  'minimum_stopped_age_days': settings['minimum_stopped_age_days'],
                  'require_approval_tag': settings['require_approval_tag'],
                  'approval_tag': settings['approval_tag_key'] + '=' + settings['approval_tag_value'],
                  'terminate_protection_behavior': settings['terminate_protection_behavior'],
                  'include_name_prefixes': settings['include_name_prefixes'],
                  'exclude_name_prefixes': settings['exclude_name_prefixes'],
                  'ignore_tag_keys': settings['ignore_tag_keys'],
                  'ignore_tag_pairs': settings['ignore_tag_pairs'],
                  'max_instances_per_run': settings['max_instances_per_run'],
                  'excluded_regions': settings['excluded_regions'],
                  'regions': [],
                  'totals': {
                      'evaluated': 0,
                      'stopped_instances': 0,
                      'old_stopped_instances': 0,
                      'eligible': 0,
                      'would_create_ami': 0,
                      'created_ami': 0,
                      'would_terminate': 0,
                      'terminated': 0,
                      'skipped_not_stopped': 0,
                      'skipped_unknown_stopped_age': 0,
                      'skipped_too_new': 0,
                      'skipped_include_prefix': 0,
                      'skipped_exclude_prefix': 0,
                      'skipped_missing_approval_tag': 0,
                      'skipped_ignore_tag': 0,
                      'skipped_termination_protection': 0,
                      'access_denied': 0,
                      'failed': 0
                  },
                  'region_results': []
              }

              try:
                  regions = get_enabled_regions(settings['excluded_regions'])
                  summary['regions'] = regions
                  for region in regions:
                      try:
                          region_result = scan_region(region, settings)
                      except Exception as e:
                          region_result = {
                              'region': region,
                              'evaluated': 0,
                              'stopped_instances': 0,
                              'old_stopped_instances': 0,
                              'eligible': 0,
                              'would_create_ami': 0,
                              'created_ami': 0,
                              'would_terminate': 0,
                              'terminated': 0,
                              'skipped_not_stopped': 0,
                              'skipped_unknown_stopped_age': 0,
                              'skipped_too_new': 0,
                              'skipped_include_prefix': 0,
                              'skipped_exclude_prefix': 0,
                              'skipped_missing_approval_tag': 0,
                              'skipped_ignore_tag': 0,
                              'skipped_termination_protection': 0,
                              'access_denied': 0,
                              'failed': 1,
                              'failures': [{'error_code': type(e).__name__, 'error_message': str(e), 'trace': traceback.format_exc()}],
                              'candidates': [],
                              'created_amis': [],
                              'terminated_instances': []
                          }
                      summary['region_results'].append(region_result)
                      for key in summary['totals']:
                          summary['totals'][key] += int(region_result.get(key, 0) or 0)
              finally:
                  summary['ended_at'] = utc_now_iso()
                  print(json.dumps(summary, indent=2, default=str))
              return summary

  RemediatorScheduleRule:
    Type: AWS::Events::Rule
    Condition: IsScheduleEnabled
    Properties:
      Name: !Sub '${ProjectName}-schedule'
      Description: Scheduled run for Stopped EC2 Instance Review Remediator.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt RemediatorFunction.Arn
          Id: StoppedEc2InstanceReviewRemediatorTarget
          Input: '{"source":"eventbridge-schedule","dry_run":true,"action_mode":"review-only"}'

  RemediatorSchedulePermission:
    Type: AWS::Lambda::Permission
    Condition: IsScheduleEnabled
    Properties:
      FunctionName: !Ref RemediatorFunction
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt RemediatorScheduleRule.Arn

Outputs:
  RemediatorFunctionName:
    Description: Lambda function name.
    Value: !Ref RemediatorFunction

  RemediatorFunctionArn:
    Description: Lambda function ARN.
    Value: !GetAtt RemediatorFunction.Arn

  DryRunSetting:
    Description: Current DryRun setting.
    Value: !Ref DryRun

  ActionModeSetting:
    Description: Current ActionMode setting.
    Value: !Ref ActionMode

  ManualReviewExample:
    Description: Example manual review invocation payload.
    Value: !Sub 'aws lambda invoke --function-name ${RemediatorFunction} --cli-binary-format raw-in-base64-out --payload ''{"dry_run":true,"action_mode":"review-only","require_approval_tag":false}'' response.json'

  ManualTerminateDryRunExample:
    Description: Example terminate dry-run invocation payload.
    Value: !Sub 'aws lambda invoke --function-name ${RemediatorFunction} --cli-binary-format raw-in-base64-out --payload ''{"dry_run":true,"action_mode":"terminate","require_approval_tag":true,"max_instances_per_run":5}'' response.json'
